前一天我們替通過 Memory Policy 的資料加入:
importance_score
並把 Retrieval 從單純的 Semantic Similarity,改成:
先通過 Relevance Threshold
再用 Relevance + Importance 重新排序
現在 Memora 已經能讓重要的 Memory 在相關 Candidates 中獲得較高順位。但目前還有一個問題:時間完全沒有參與判斷。假設 Store 中有兩筆內容:
使用者三年前曾準備 TOEIC。
使用者上週開始準備 TOEIC。
如果兩筆文字的 Embedding 都和目前 Query 很接近,而且 Importance 也相同,現在的 Ranking 不知道哪一筆比較值得優先取回。今天要在昨天的 Retrieval Score 中加入第三個訊號:
Recency
同時也要釐清一件事:
Memory Decay 不等於立即刪除。它可以先讓很久沒有使用的 Memory 逐漸降低順位,真正刪除則需要另一個明確動作。
Recency 可以先理解成:
這筆 Memory 最近有沒有被實際使用。
Day 17 已經替每筆資料保存:
created_at
但它只能回答:
這筆 Record 什麼時候被建立?
今天要新增:
last_accessed_at
它回答的是:
這筆 Memory 最近一次被選進模型 Context 是什麼時候?
兩者的用途不同:
| 欄位 | 意義 | 是否會更新 |
|---|---|---|
created_at |
Memory 寫進 Store 的時間 | 不會 |
last_accessed_at |
最近一次被模型使用的時間 | 會 |
這裡沿用 Day 20 的界線:created_at 不是事件真正發生的時間。今天的 last_accessed_at 也不是使用者最後一次提到這件事的時間,而是 Application 最後一次把它放入模型 Context 的時間。
「遺忘」在 Memory System 裡不只一種意思。
Memory 還存在 Store 中,但 Recency Score 隨時間下降:
剛使用過
→ Recency 高
很久沒使用
→ Recency 低
Memory 沒有被刪除,只是因為 Ranking 較低,長時間沒有進入 Context。從模型的角度看,它像是暫時想不起來;從 Database 的角度看,資料仍然存在。
Record 真正從 Long-term Memory Store 中刪除:
Chroma Record
→ delete
→ 不再存在
今天會完成:
自動 Memory Decay
自動 Soft Forgetting
明確指令觸發的 Hard Forgetting
但不會因為一筆 Memory 很舊,就讓 Application 自動永久刪除。等到 Day 29,才會進一步討論 Agent 是否能自己決定何時忘記。
last_accessed_at先從 Day 22 的 LongTermMemoryStore.add() 繼續修改。
原本已經有:
created_at = datetime.now(
timezone.utc
).isoformat()
新 Memory 剛建立時,還沒有被 Retrieval 找回過。為了讓它具有有效的初始 Recency,可以先令:
last_accessed_at = created_at
接著修改 Chroma Metadata:
metadatas=[
{
"source": source,
"created_at": created_at,
"last_accessed_at": last_accessed_at,
"memory_type": memory.memory_type,
"importance_score": (
memory.importance_score
)
}
for memory in memories
]
其他欄位全部保留。現在一筆 Record 可能是:
Document
The user wants to pass B2 in three months.
Metadata
source = automatic
created_at = 2026-09-14T03:20:00+00:00
last_accessed_at = 2026-09-14T03:20:00+00:00
memory_type = semantic
importance_score = 5
新建立的 Memory 不會因為尚未被搜尋過,就立刻得到最低 Recency。
Day 22 的 StoredMemory 與 MemorySearchResult 已經包含 importance_score。今天只增加同一個欄位:
class StoredMemory(BaseModel):
memory_id: str
content: str
memory_type: StoredMemoryType
importance_score: int = Field(
ge=1,
le=5
)
source: str
created_at: str
last_accessed_at: str
class MemorySearchResult(BaseModel):
memory_id: str
content: str
memory_type: StoredMemoryType
importance_score: int = Field(
ge=1,
le=5
)
source: str
created_at: str
last_accessed_at: str
distance: float
score: float
這裡不把 recency_score 存進 Model 或 Chroma。
因為 Recency 會隨目前時間改變。今天算出的分數到了明天就已經不同,所以應該在 Retrieval 時即時計算,而不是把一個很快過期的結果寫進 Database。
last_accessed_at 的資料Day 22 以前的舊 Record 沒有新欄位,因此新增:
def parse_utc_timestamp(
value: str
) -> datetime | None:
try:
timestamp = datetime.fromisoformat(value)
except (TypeError, ValueError):
return None
if timestamp.tzinfo is None:
timestamp = timestamp.replace(
tzinfo=timezone.utc
)
return timestamp.astimezone(
timezone.utc
)
接著建立:
def read_last_accessed_at(
metadata: dict
) -> str:
last_accessed_at = metadata.get(
"last_accessed_at"
)
if (
isinstance(last_accessed_at, str)
and parse_utc_timestamp(
last_accessed_at
) is not None
):
return last_accessed_at
created_at = metadata.get(
"created_at"
)
if (
isinstance(created_at, str)
and parse_utc_timestamp(
created_at
) is not None
):
return created_at
return "unknown"
讀取規則是:
有 last_accessed_at
→ 使用 last_accessed_at
沒有 last_accessed_at,但有 created_at
→ 暫時使用 created_at
兩者都無法解析
→ unknown
這只是舊資料的 Compatibility Fallback,不能證明舊 Memory 最後一次真的在 created_at 被使用。最後在 list_all() 建立 StoredMemory,以及 search() 建立 MemorySearchResult 時加入:
last_accessed_at=read_last_accessed_at(
metadata
)
原本的 read_memory_type() 與 read_importance_score() 繼續保留。
Generative Agents 的研究使用指數衰退計算 Recency。Memora 也採用 Exponential Decay,但改用比較容易理解的 Half-life 表示。
先設定:
RECENCY_HALF_LIFE_DAYS = 7
DEFAULT_RECENCY_SCORE = 0.5
計算公式是:
Recency Score
= 0.5 ^ (經過天數 / Half-life)
當 Half-life 是七天:
| 距離上次使用 | Recency Score |
|---|---|
| 0 天 | 1.000 |
| 7 天 | 0.500 |
| 14 天 | 0.250 |
| 21 天 | 0.125 |
| 28 天 | 0.063 |
它不會在第七天突然從 1 掉到 0.5,而是每天平滑下降。
calculate_recency_score()新增:
def calculate_recency_score(
last_accessed_at: str,
now: datetime | None = None
) -> float:
accessed_at = parse_utc_timestamp(
last_accessed_at
)
if accessed_at is None:
return DEFAULT_RECENCY_SCORE
if now is None:
now = datetime.now(
timezone.utc
)
elif now.tzinfo is None:
now = now.replace(
tzinfo=timezone.utc
)
else:
now = now.astimezone(
timezone.utc
)
age_seconds = max(
0.0,
(now - accessed_at).total_seconds()
)
age_days = age_seconds / 86400
return 0.5 ** (
age_days
/ RECENCY_HALF_LIFE_DAYS
)
如果 Timestamp 無法解析,先回傳中立值:
0.5
不直接回傳 0,是為了避免舊資料只因為缺少新欄位,就被當成完全沒有價值。如果資料時間意外晚於目前時間,max(0.0, ...) 會把經過時間限制為零,避免產生大於 1 的 Recency Score。
Day 22 的權重是:
RELEVANCE_WEIGHT = 0.8
IMPORTANCE_WEIGHT = 0.2
今天加入 Recency,改成:
RELEVANCE_WEIGHT = 0.7
IMPORTANCE_WEIGHT = 0.2
RECENCY_WEIGHT = 0.1
三個權重加總仍然是 1.0。
接著修改原本的 calculate_retrieval_score():
def calculate_retrieval_score(
result: MemorySearchResult,
now: datetime | None = None
) -> float:
relevance_score = max(
0.0,
min(1.0, result.score)
)
importance_score = normalize_importance(
result.importance_score
)
recency_score = calculate_recency_score(
result.last_accessed_at,
now=now
)
return (
RELEVANCE_WEIGHT
* relevance_score
+ IMPORTANCE_WEIGHT
* importance_score
+ RECENCY_WEIGHT
* recency_score
)
昨蔫的 normalize_importance() 不需要修改。
目前分數可以理解成:
Relevance
這筆 Memory 和現在的問題有多相關?
Importance
它對未來英文學習有多重要?
Recency
它最近有沒有被使用?
retrieve_relevant_memories()Day 22 已經先做 Relevance Threshold,再進行 Re-ranking。這個順序今天繼續保留。
只修改排序位置:
def retrieve_relevant_memories(
query: str
) -> tuple[list[MemorySearchResult], int]:
(
candidates,
embedding_tokens
) = semantic_search(
query=query,
top_k=RETRIEVAL_CANDIDATE_K
)
relevant_candidates = [
result
for result in candidates
if result.score
>= RETRIEVAL_MIN_SCORE
]
ranking_time = datetime.now(
timezone.utc
)
ranked_memories = sorted(
relevant_candidates,
key=lambda result: (
calculate_retrieval_score(
result,
now=ranking_time
)
),
reverse=True
)
return (
ranked_memories[
:RETRIEVAL_LIMIT
],
embedding_tokens
)
這裡先建立一次:
ranking_time
再用同一個時間計算所有 Candidates,避免排序同一批資料時,每筆 Memory 使用略微不同的現在時間。
而且仍然先執行:
result.score >= RETRIEVAL_MIN_SCORE
所以高 Importance 或高 Recency 不能把完全不相關的 Memory 強行送進 Context。
假設兩筆 Memory 都通過 Relevance Threshold:
| Memory | Relevance | Importance | 距離上次使用 |
|---|---|---|---|
| A:三個月後要參加 B2 檢定 | 0.78 | 5 | 30 天 |
| B:上週做過機場單字練習 | 0.72 | 2 | 1 天 |
當 Half-life 是七天時:
Memory A Recency
≈ 0.5 ^ (30 / 7)
≈ 0.051
因此 A 的 Retrieval Score 約為:
0.7 × 0.78
+ 0.2 × 1.00
+ 0.1 × 0.051
= 0.751
Memory B 的 Importance 2 會被正規化成 0.25,Recency 約為 0.906:
0.7 × 0.72
+ 0.2 × 0.25
+ 0.1 × 0.906
= 0.645
所以 A 雖然很久沒被使用,仍然可能因為 Relevance 與 Importance 較高而排在前面。
Decay 不是「時間久了就一律沒用」,而是讓時間成為 Ranking 的其中一個訊號。
如果 Chroma 回傳八筆 Candidates,但最後只有三筆通過篩選並被放入 Context,不能把八筆全部更新成「剛使用過」。
今天把 Access 定義成:
Memory 被選入成功送給模型的 Context。
因此下面這些操作不更新 last_accessed_at:
被 Chroma 列為 Candidate,但最後沒有入選
輸入 memories 查看所有資料
輸入 search 執行開發階段的搜尋測試
只有一般聊天中真正進入:
background_messages
並成功完成 Responses API Request 的 Retrieved Memory,才算被使用。
LongTermMemoryStore 加入 touch()接下來要更新 Chroma Metadata。
新增:
def touch(
self,
memory_ids: list[str]
):
unique_ids = list(
dict.fromkeys(memory_ids)
)
if not unique_ids:
return
records = self.collection.get(
ids=unique_ids,
include=["metadatas"]
)
record_ids = records.get(
"ids",
[]
)
record_metadatas = (
records.get("metadatas")
or [{} for _ in record_ids]
)
metadata_by_id = {
memory_id: dict(metadata or {})
for memory_id, metadata in zip(
record_ids,
record_metadatas
)
}
accessed_at = datetime.now(
timezone.utc
).isoformat()
updated_ids = []
updated_metadatas = []
for memory_id in unique_ids:
if memory_id not in metadata_by_id:
continue
metadata = metadata_by_id[
memory_id
]
metadata["last_accessed_at"] = (
accessed_at
)
updated_ids.append(memory_id)
updated_metadatas.append(metadata)
if not updated_ids:
return
self.collection.update(
ids=updated_ids,
metadatas=updated_metadatas
)
這裡先讀回原本 Metadata,再只修改:
last_accessed_at
最後把完整 Metadata 寫回去,避免更新時間時不小心丟掉:
source
created_at
memory_type
importance_score
touch() 不修改 Document 或 Embedding,因此不需要重新呼叫 Embeddings API。
不要在 semantic_search() 剛找到 Candidates 時立即呼叫 touch()。因為模型 Request 仍然可能失敗。如果 Request 沒有成功,這些 Memory 就沒有真正完成本次使用。Day22的主迴圈取得 assistant_reply 後,加入:
retrieved_memory_ids = [
memory_item.memory_id
for memory_item in retrieved_memories
]
try:
long_term_memory.touch(
retrieved_memory_ids
)
except Exception as error:
print(
"Could not update memory access time:",
error
)
接著再保留原本的:
memory.finish_turn(
assistant_reply=assistant_reply,
context_messages=(
memory_stats["context_messages"]
),
response_tokens=(
response.usage.total_tokens
)
)
touch() 失敗時只顯示 Warning,不丟棄已經成功取得的 Assistant Response。
這次錯誤處理和主要模型 Request 分開,是因為兩者的影響不同:
Responses API 失敗
→ 本輪沒有回答
touch() 失敗
→ 回答仍然有效,只是 Recency 沒有更新
memories Command 可以再增加:
recency_score = calculate_recency_score(
stored_memory.last_accessed_at
)
print(
f" Last accessed: "
f"{stored_memory.last_accessed_at}"
)
print(
f" Recency: "
f"{recency_score:.3f}"
)
結果可能是:
1. 使用者希望三個月後通過 B2 檢定。
ID: 4d92...
Type: semantic
Importance: 5/5
Last accessed: 2026-09-14T03:20:00+00:00
Recency: 0.998
2. 使用者曾經完成機場英文練習。
ID: 6a71...
Type: episodic
Importance: 2/5
Last accessed: 2026-08-17T03:20:00+00:00
Recency: 0.063
這裡也把完整 memory_id 顯示出來,因為接下來 Hard Forgetting 會使用它。
只執行 memories 查看資料時,不呼叫 touch()。Debug 不應該改變正式 Retrieval 的 Access History。
假設一筆重要的 Memory 很久沒有被取回:
使用者的長期目標是準備 IELTS。
即使它的 Recency 已經很低,也不代表 Application 應該只根據時間把它刪除。
反過來,一筆 Importance 很低的資料,也可能在某個特殊 Query 出現時突然變得高度相關。
所以目前不建立這種規則:
if recency_score < 0.1:
delete_memory()
Recency Score 的功能是調整 Retrieval 順序,不是宣判資料已經沒有價值。
如果未來要自動清理,至少還要一起考慮:
Importance
多久沒有被 Access
Memory Type
是否仍存在於 User Profile
是否有新的 Memory 取代它
使用者是否要求保留
其中「是否有新資料取代」會和 Day 24 的 Update 與 Contradiction 直接相關。
雖然今天不讓時間自動刪除 Memory,仍然可以先提供一個明確的刪除介面。
在 LongTermMemoryStore 新增:
def delete(
self,
memory_id: str
) -> bool:
records = self.collection.get(
ids=[memory_id],
include=["metadatas"]
)
if not records.get("ids"):
return False
self.collection.delete(
ids=[memory_id]
)
return True
這裡先檢查 ID 是否存在,再執行 Chroma 的 delete()。
接著在原本 Command Branch 中加入:
if command_name == "forget":
memory_id = command_value.strip()
if not memory_id:
print(
"Usage: forget <memory_id>"
)
continue
deleted = long_term_memory.delete(
memory_id
)
if deleted:
print("Memory deleted.")
else:
print("Memory not found.")
continue
使用方式是先輸入:
memories
找到完整 ID,再輸入:
forget 4d92f23c-...
這個指令使用完整 ID,而不是直接用一段自然語言猜測要刪除哪一筆,避免兩筆語意相近的 Memory 被刪錯。
Day 19 已經把 User Profile 和可搜尋的 Long-term Memory 分開。
因此:
forget <memory_id>
只刪除 Chroma 中對應的 Memory Record,不會修改:
user_profile.json
假設下面的資訊同時存在兩邊:
Long-term Memory
使用者的英文程度是 B1。
User Profile
english_level = B1
刪除 Long-term Memory 後,Profile 仍然會把 B1 放進 Background Context。
這不是刪除失敗,而是兩個 Store 本來就具有不同責任。若要更改目前採用的英文程度,仍然應使用 Day 19 的 Profile Command。
更新 last_accessed_at 之後,還有一個值得注意的現象。
某筆 Memory 一旦被取回:
被放進 Context
→ last_accessed_at 更新
→ Recency 變高
→ 下一次更容易再次被取回
這可能形成 Feedback Loop,讓少數經常出現的 Memory 長期佔據 Ranking 前面。
目前有三個設計可以降低這個問題:
先通過 Relevance Threshold
Recency Weight 只設定為 0.1
只 Touch 最後真正入選的 Memory
但它仍然不可能完全避免偏差。之後可以透過不同類型配額、Retrieval Diversity 或更完整的評估資料繼續調整。
因此 Weight 不是寫完就永遠不變的常數,而是需要依實際對話測試的 Hyperparameter。
首先建立兩筆 Memory:
remember semantic 使用者希望準備 B2 檢定。
remember episodic 使用者今天完成機場英文練習。
輸入:
memories
確認新 Record 具有:
created_at
last_accessed_at
importance_score
接著問一個相關問題:
幫我安排 B2 檢定的讀書計畫。
再次輸入 memories,確認真正進入本輪 Context 的 Memory,其 last_accessed_at 已經更新。
也要檢查以下情況:
Chroma 找到但未進入前 3 名的 Candidate
→ 不更新
memories 或 search Debug
→ 不更新
Responses API Request 失敗
→ 不更新
最後複製其中一筆完整 ID:
forget <memory_id>
再輸入:
memories
確認該 Record 已經不存在。
現在的 Memora 已經會:
讓久未使用的 Memory 逐漸降低 Recency
在 Ranking 中產生 Soft Forgetting
依照明確指令刪除指定 Memory
但它還不會自己思考:
這筆資料是不是已經過時?
是否有新資料可以取代它?
兩筆互相矛盾時應該刪除哪一筆?
這次應該更新舊 Record,還是建立新 Record?
這些問題不能單靠時間回答。
因此今天只讓時間影響 Retrieval,並保留可控的刪除介面;不把「舊」直接等同於「錯」或「沒有價值」。
今天直接從 Day 22 的 Importance Ranking 繼續,保留原本的:
Memory Policy
Importance Scoring
Embedding
LongTermMemoryStore
Relevance Threshold
Retrieval Re-ranking
User Profile
Short-term Context Management
新增的是:
last_accessed_at
parse_utc_timestamp()
read_last_accessed_at()
calculate_recency_score()
RECENCY_WEIGHT
LongTermMemoryStore.touch()
LongTermMemoryStore.delete()
forget <memory_id>
Retrieval Ranking 現在由三個訊號組成:
Relevance
+ Importance
+ Recency
但順序仍然是:
Semantic Search
→ Relevance Threshold
→ 綜合 Ranking
→ 選出要進入 Context 的 Memory
→ 成功回答後更新 last_accessed_at
今天最重要的觀念是:
Memory Decay 不必立刻刪除資料。先讓久未使用的 Memory 降低 Retrieval 順位,就能形成可逆的 Soft Forgetting;真正的 Hard Forgetting 則應該透過明確而可控的刪除動作完成。
不過,目前 Store 中仍然可能同時存在:
重複的 Memory
已經過時的 Memory
互相矛盾的 Memory
只降低舊資料的 Recency,並不能判斷哪一筆才是目前正確的狀態。
下一篇我們會從今天的 LongTermMemoryStore 繼續修改,在新增 Memory 前先搜尋相近資料,判斷應該:
建立新的 Record
略過重複內容
更新既有 Memory
保留具有時間意義的不同事件
處理互相矛盾的狀態
今天讓 Memory 的使用機會隨時間改變;Day 24 則要開始維護 Memory 內容本身的一致性。